package de.reondo.jos;

import java.io.File;
import java.io.FilenameFilter;
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;

import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.CommandLineParser;
import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
import org.apache.commons.cli.PosixParser;
import org.jgrapht.EdgeFactory;
import org.jgrapht.Graph;
import org.jgrapht.alg.CycleDetector;
import org.jgrapht.graph.DefaultDirectedWeightedGraph;

/**
 * Load and analyze dependencies of all classes found in
 * jar files specified on command line (either whole directory containing jars or a single .jar file).
 * 
 * When running the program on a lot of jar archives, you might want to increase the "perm size" of the JVM:  
 * -XX:MaxPermSize=256M
 * 
 * @author dw
 *
 */
public class JarMain {

    private boolean reduceJarGraph;
    private boolean runDotProgram;
    private Pattern includePattern;
    private String dotOutputFormat;
    private boolean generateTGF;
    private MyConsoleLogger log;


    public JarMain(boolean reduceJarGraph, boolean runDotProgram, boolean verbose, Pattern includePattern, String dotOutputFormat, boolean generateTGF) {
        this.reduceJarGraph = reduceJarGraph;
        this.runDotProgram = runDotProgram;
        this.includePattern = includePattern;
        this.dotOutputFormat = dotOutputFormat;
        this.generateTGF = generateTGF;
        this.log = new MyConsoleLogger(verbose);
    }


//    private static boolean getBooleanOption(String optionValue, boolean defaultValue) {
//        if (optionValue == null)
//            return defaultValue;
//        if (Arrays.asList("no", "0", "false", "n").contains(optionValue.toLowerCase())) {
//            return false;
//        } else if (Arrays.asList("yes", "1", "true", "y").contains(optionValue.toLowerCase())) {
//            return true;
//        }
//        return defaultValue;
//    }

    
    /**
     * @param args
     * @throws Exception 
     */
    public static void main(String[] args) throws Exception {
        Options opts = new Options();
        opts.addOption("o", "outfile", true, "output file name(s) (default: ./jargraph)");
        opts.addOption("r", "reduce", false, "perform transitive reduction on graph");
        opts.addOption("d", "dot", false, "Generate .dot file and run dot program (requires Graphviz on PATH)");
        opts.addOption("t", "tgf", false, "Generate .tgf (Trivial Graph Format) file");
        opts.addOption("f", "format", true, "dot output file format (pdf, png, svg. default: svg)");
        opts.addOption("v", "verbose", false, "be verbose and log progress");
        opts.addOption("i", "include", true, "only include jars in analysis that match pattern (comma separated, e.g. -i mylib,other.*stuff)");
        CommandLineParser parser = new PosixParser();
        CommandLine cmd = null;
        try {
            cmd = parser.parse(opts, args);
        } catch (ParseException pe) {
            System.err.println("Unknown option: " + pe.getMessage());
            HelpFormatter formatter = new HelpFormatter();
            formatter.printHelp("jargraph [options] jar-file-or-dir [jar-file-or-dir ...]", opts);
            System.exit(1);
        }
        String outfile = cmd.getOptionValue("o", "jargraph");
        boolean reduceJarGraph = cmd.hasOption("r");
        boolean runDotProgram = cmd.hasOption("d");
        boolean verbose = cmd.hasOption("v");
        boolean generateTGF = cmd.hasOption("t");
        Pattern includePattern; 
        try {
            String regex = cmd.getOptionValue("i", ".*");
            String[] parts = regex.split(",");
            regex = "";
            for (String part : parts) {
                regex += "|(.*" + part + ".*)";
            }
            regex = (regex.length() > 0) ? regex.substring(1) : regex;
            includePattern = Pattern.compile(regex);
        } catch (PatternSyntaxException pse) {
            System.err.println("Invalid regular expression for -i option");
            System.exit(2);
            return;
        }
        String dotOutputFormat = cmd.getOptionValue("f", "svg");
        String[] runArgs = cmd.getArgs();
        if (runArgs.length == 0) {
            System.err.println("No jar files specified");
            System.exit(3);
        }
        new JarMain(reduceJarGraph, runDotProgram, verbose, includePattern, dotOutputFormat, generateTGF).run(outfile, runArgs);
    }

    private void run(String outfile, String[] jarFilenames) throws Exception {
        List<URL> jarUrls = new ArrayList<URL>();
        List<JarFile> jarFiles = new ArrayList<JarFile>();
        List<String> failedClasses = new ArrayList<String>();
        HashMap<ClassInfo, String> classToJarFileMap = new HashMap<ClassInfo, String>();
        // Collect JAR files (URLs for ClassLoader, JarFiles for iteration)
        int total = 0;
        for (String arg : jarFilenames) {
            File file = new File(arg);
            if (file.exists() && file.isFile()) {
                jarUrls.add(file.toURI().toURL());
                jarFiles.add(new JarFile(file));
            } else if (file.exists() && file.isDirectory()) {
                File[] jars = file.listFiles(new JarFilenameFilter());
                for (File jar : jars) {
                    jarUrls.add(jar.toURI().toURL());
                    jarFiles.add(new JarFile(jar));                    
                }
            } else {
                log.println("File " + file.getPath() + " does not exist. Skipping it.");
            }
        }
        // Create class loader from collected JARs
        ClassLoader cl = URLClassLoader.newInstance(jarUrls.toArray(new URL[0]));
        // Analyze jar files matching filter criteria
        ClassAnalyser analyser = new ClassAnalyser();
        int countAnalysedJars = 0;
        int countAnalysedClasses = 0;
        for (JarFile jarFile : jarFiles) {
            if (! includePattern.matcher(jarFile.getName()).matches()) {
                log.verbose("Skipping file " + jarFile.getName() + " not matching include pattern.");
                continue;
            } 
            ++countAnalysedJars;
            Enumeration<JarEntry> jenum = jarFile.entries();
            // Look into jar file
            while (jenum.hasMoreElements()) {
                JarEntry jentry = jenum.nextElement();
                String cname = className(jentry.getName());
                if (cname != null) {
                    try {
                        Class<?> cls = cl.loadClass(cname);
                        ++countAnalysedClasses;
                        // send class to analyser and memorize which jar file the class came from
                        analyser.extend(cls);
                        classToJarFileMap.put(new ClassInfo(cls), jarFile.getName());
                    } catch (NoClassDefFoundError ncd) {
                        failedClasses.add(jarFile.getName() + ":" + jentry.getName());
                    } catch (TypeNotPresentException tnp) {
                        failedClasses.add(jarFile.getName() + ":" + jentry.getName());
                    } catch (ClassNotFoundException cnf) {
                        failedClasses.add(jarFile.getName() + ":" + jentry.getName());
                    }
                    total++;
                }
                if (total > 0 && total % 1000 == 0) { 
                    log.verbose("Current status: graph contains " + analyser.getGraph().vertexSet().size() + " vertices.");
                    log.verbose("    There were " + failedClasses.size() + " class loading failures.");
                }
            }
        }
        
        log.println("Analysed " + countAnalysedClasses + " classes in " + countAnalysedJars + " jar archives.");
        
        if (log.isVerbose()) {
            // show some failed classes
            StringBuilder buf = new StringBuilder();
            Collections.sort(failedClasses);
            for (int i = 0; i < Math.min(failedClasses.size(), 16); ++i) {
                buf.append("\n  ").append(failedClasses.get(i));
            }
            log.verbose("The following classes could not be analysed due to errors:" + buf);
            if (failedClasses.size() > 16) {
                log.verbose("... and " + (failedClasses.size() - 16) + " more.");
            }
        } else if (! failedClasses.isEmpty()){
            log.println("Analysis done. " + failedClasses.size() + " classes could not be analysed due to errors.");
        }
        // Now do something with the class graph
        createAndPrintJarGraph(classToJarFileMap, analyser, outfile);
    }

    private void createAndPrintJarGraph(HashMap<ClassInfo, String> classToJarFileMap, ClassAnalyser analyser, String filenameBase)
            throws IOException {
        DefaultDirectedWeightedGraph<String, JarDependencyEdge> jarGraph = 
                new DefaultDirectedWeightedGraph<String, JarDependencyEdge>(new JarDependencyEdgeFactory());
        Graph<ClassInfo, ClassInfoDependencyEdge> classGraph = analyser.getGraph();
        // Iterate over all class dependencies and map each one to a jar dependency, given the classToJarFileMap
        // Multiple dependencies between two jar files are counted in the 'weight' attribute of the jarGraph
        for (ClassInfoDependencyEdge edge : classGraph.edgeSet()) {
            String srcFull = classToJarFileMap.get(classGraph.getEdgeSource(edge));
            String tgtFull = classToJarFileMap.get(classGraph.getEdgeTarget(edge));
            if (srcFull == null || tgtFull == null || srcFull.equals(tgtFull))
                continue;
            String src = new File(srcFull).getName();
            String tgt = new File(tgtFull).getName();
            jarGraph.addVertex(src);
            jarGraph.addVertex(tgt);
            
            if (jarGraph.containsEdge(src, tgt)) {
                JarDependencyEdge e = jarGraph.getEdge(src, tgt);
                jarGraph.setEdgeWeight(e, jarGraph.getEdgeWeight(e) + 1.0);
            } else {
                JarDependencyEdge e = jarGraph.addEdge(src, tgt);
                e.setWitness(edge.getOccurrence());
                jarGraph.setEdgeWeight(e, 1.0);
            }
        }
        
        CycleDetector<String, JarDependencyEdge> cd = new CycleDetector<String, JarDependencyEdge>(jarGraph);
        if (reduceJarGraph) {
            if (cd.detectCycles()) {
                log.println("JarGraph contains cycles. No reduction attempted.");
            } else {
                reduceJarGraph(jarGraph);
            }
        }
        JarGraphStyler graphStyler = new JarGraphStyler(jarGraph);
        if (runDotProgram) {
            DependencyGraphPrinter<String, JarDependencyEdge> dependencyGraphPrinter = 
                    new DependencyGraphPrinter<String, JarDependencyEdge>(graphStyler);
            String dotFilename = filenameBase + ".dot";
            dependencyGraphPrinter.writeAsDotFile(jarGraph, dotFilename);
            log.println("Wrote .dot file to " + dotFilename);
            runDot(dotFilename, dotOutputFormat);
        }
        if (generateTGF) {
            DependencyGraphPrinter<String, JarDependencyEdge> dependencyGraphPrinter = 
                    new DependencyGraphPrinter<String, JarDependencyEdge>(graphStyler);
            String tgfFilename = filenameBase + ".tgf";
            dependencyGraphPrinter.writeAsTgfFile(jarGraph, tgfFilename);
            log.println("Wrote .tgf file to " + tgfFilename);
        }
        
        DependencyListPrinter<String, JarDependencyEdge> p = new DependencyListPrinter<String, JarDependencyEdge>(jarGraph, graphStyler);
        p.printDependencyList(filenameBase + ".txt");
        log.println("Wrote dependency information to " + filenameBase + ".txt");
    }


    /*
     * Reduces the number of edges in the graph by performing a transitive reduction.
     * Callers mus decide if this is a semantics preserving operation for their graph.
     */
    private void reduceJarGraph(DefaultDirectedWeightedGraph<String, JarDependencyEdge> jarGraph) {
        int removedEdges = 0;
        for (String v1 : jarGraph.vertexSet()) {
            for (String v2 : jarGraph.vertexSet()) {
                if (v1.equals(v2)) 
                    continue;
                if (! jarGraph.containsEdge(v1, v2))
                    continue;
                for (String v3 : jarGraph.vertexSet()) {
                    if (v3.equals(v1) || v3.equals(v2)) 
                        continue;
                    if (jarGraph.containsEdge(v1, v3) && jarGraph.containsEdge(v2, v3)) {
                        // v1->v2->v3, v1->v3  ==> v1->v2->v3
                        jarGraph.removeEdge(v1, v3);
                        removedEdges++;
                    }
                }
            }
        }
        log.println("Removed " + removedEdges + " edges from graph by transitive reduction.");
    }

    // check if given name represents a class file. 
    private static String className(String name) {
        if (name.endsWith(".class")) {
            return name.replace(".class", "").replace("/", ".");
        } else {
            return null;
        }
    }

    // Helper class for use by JGraphT 
    private static final class JarDependencyEdgeFactory implements EdgeFactory<String, JarDependencyEdge> {
        @Override
        public JarDependencyEdge createEdge(String src, String tgt) {
            return new JarDependencyEdge(src, tgt);
        }
    }

    private static final class JarFilenameFilter implements FilenameFilter {
        @Override
        public boolean accept(File dir, String name) {
            return name.toLowerCase().endsWith(".jar");
        }
    }

    // run the dot program, assuming it is on the PATH
    public void runDot(String dotFile, String format) throws IOException {
        Runtime.getRuntime().exec("dot -O -T" + format + " " + dotFile);
    }
}
